<template>
  <div ref="canvasContainerRef" :class="$props.class" aria-hidden="true">
    <canvas ref="canvasRef"></canvas>
  </div>
</template>

<script setup lang="ts">
import { useMouse, useDevicePixelRatio } from "@vueuse/core";
import { ref, onMounted, onBeforeUnmount, watch, computed, reactive } from "vue";

// 定义一个“圆点粒子”的数据结构，每一个在画布上飘动的小点就是一个 Circle 对象
// 这么写可以把一堆相关的数据（坐标、速度、透明度等）打包到一起，方便后面传来传去
type Circle = {
  x: number;
  y: number;
  translateX: number;
  translateY: number;
  size: number;
  alpha: number;
  targetAlpha: number;
  dx: number;
  dy: number;
  magnetism: number;
};

// 定义这个组件可以接收的外部参数（props）
// 这么写的好处：在使用组件时可以通过属性控制粒子效果，而不用改组件内部代码
type Props = {
  color?: string;
  quantity?: number;
  staticity?: number;
  ease?: number;
  class?: string;
};

// 给 props 设置类型并提供默认值
// 使用 withDefaults 是为了：父组件就算不传这些 props，这个组件也能有合理的默认行为
const props = withDefaults(defineProps<Props>(), {
  color: "#FFF",
  quantity: 100,
  staticity: 50,
  ease: 50,
  class: "",
});

// 获取 canvas 元素的引用，后面要通过它拿到 2D 绘图上下文
const canvasRef = ref<HTMLCanvasElement | null>(null);
// 获取包裹 canvas 的 div 容器，用来计算画布的宽高
const canvasContainerRef = ref<HTMLDivElement | null>(null);
// 2D 绘图上下文，相当于我们操作画布的“画笔”
const context = ref<CanvasRenderingContext2D | null>(null);
// 保存所有粒子的数组，每一个元素都是一个 Circle
const circles = ref<Circle[]>([]);
// 响应式的鼠标位置（相对于画布中心），用于让粒子跟随鼠标产生“磁力”效果
const mouse = reactive<{ x: number; y: number }>({ x: 0, y: 0 });
// 响应式的画布尺寸，窗口大小变化时会更新
const canvasSize = reactive<{ w: number; h: number }>({ w: 0, h: 0 });
// 使用 @vueuse/core 的 useMouse 获取当前鼠标在页面中的位置
const { x: mouseX, y: mouseY } = useMouse();
// 使用 @vueuse/core 的 useDevicePixelRatio 获取设备像素比（如 Retina 屏一般是 2）
// 这么写的原因：根据像素比调整画布大小，让画面在高清屏上也不会模糊
const { pixelRatio } = useDevicePixelRatio();

// 计算属性：把传进来的十六进制颜色（例如 "#fff" 或 "#ffffff"）转换成 "R G B" 形式的字符串
// 为什么要这么写：canvas 的 fillStyle 需要使用 "rgba(r,g,b,a)" 形式，
// 所以这里先把 hex 颜色解析成单独的 R/G/B 数值，后面绘制时直接复用
// 这么写会导致：只要外部修改 props.color，这里的 color 会自动重新计算，粒子颜色也会自动更新
const color = computed(() => {
  // 去掉开头的 '#'，方便后面解析
  let hex = props.color.replace(/^#/, "");

  // 如果是 3 位的简写形式（比如 "#fff"），就展开成 6 位（"ffffff"）
  if (hex.length === 3) {
    hex = hex
      .split("")
      .map((char) => char + char)
      .join("");
  }

  // 把十六进制字符串转换成整数，然后分别取出 R、G、B 三个通道
  const bigint = parseInt(hex, 16);
  const r = (bigint >> 16) & 255;
  const g = (bigint >> 8) & 255;
  const b = bigint & 255;

  // 返回用空格分隔的 "R G B" 字符串，后面会再拼成 "rgba(r,g,b,a)"
  return `${r} ${g} ${b}`;
});

// 组件挂载到页面后执行的生命周期钩子
// 为什么要在这里写初始化逻辑：只有当 DOM 元素真的渲染出来后，我们才能拿到 canvas 的真实尺寸和上下文
onMounted(() => {
  // 安全地获取 canvas 的 2D 上下文对象，如果拿不到就不继续往下画
  if (canvasRef.value) {
    context.value = canvasRef.value.getContext("2d");
  }

  // 初始化画布大小并生成初始粒子
  initCanvas();
  // 启动动画循环，让粒子持续运动
  animate();
  // 监听窗口尺寸变化，保证画布在窗口缩放时能自适应
  window.addEventListener("resize", initCanvas);
});

// 组件从页面卸载前执行的生命周期钩子
// 这么写的原因：把监听器移除，避免组件销毁后还在触发回调，造成内存泄漏
onBeforeUnmount(() => {
  window.removeEventListener("resize", initCanvas);
});

// 监听鼠标在页面上的坐标变化
// 为什么使用 watch：当 mouseX 或 mouseY 任意一个改变时，我们都重新计算粒子相对鼠标的位置
watch([mouseX, mouseY], () => {
  // 每次鼠标移动时，更新内部的 mouse（相对于画布中心的坐标）
  onMouseMove();
});

// 初始化画布：先根据容器尺寸调整 canvas，再生成一批初始粒子
// 这么拆分成一个单独函数的好处：挂载时、窗口缩放时都可以重复调用这段逻辑
function initCanvas() {
  resizeCanvas();
  drawParticles();
}

// 根据当前鼠标在页面上的位置，计算鼠标相对于画布中心的位置
// 为什么要这么写：粒子是围绕画布中心计算“偏移量”的，所以要把全局坐标转换为相对坐标
function onMouseMove() {
  if (canvasRef.value) {
    // getBoundingClientRect 用来获取 canvas 在页面中的位置和大小
    const rect = canvasRef.value.getBoundingClientRect();
    const { w, h } = canvasSize;
    // 鼠标在画布坐标系中的位置（以画布中心为原点）
    const x = mouseX.value - rect.left - w / 2;
    const y = mouseY.value - rect.top - h / 2;

    // inside 表示鼠标是否还在画布范围内，超出就不更新（避免粒子突然乱飞）
    const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2;
    if (inside) {
      mouse.x = x;
      mouse.y = y;
    }
  }
}

// 根据容器的实际尺寸重新设置 canvas 的宽高
// 为什么要这么写：canvas 的真实像素宽高需要手动设置，否则只是 CSS 拉伸，会导致画面模糊
function resizeCanvas() {
  if (canvasContainerRef.value && canvasRef.value && context.value) {
    // 每次重置画布时清空已有的粒子，避免重复绘制
    circles.value.length = 0;
    // 使用容器的 offsetWidth / offsetHeight 作为画布可见尺寸
    canvasSize.w = canvasContainerRef.value.offsetWidth;
    canvasSize.h = canvasContainerRef.value.offsetHeight;
    // 按照设备像素比放大实际像素尺寸，提高清晰度
    canvasRef.value.width = canvasSize.w * pixelRatio.value;
    canvasRef.value.height = canvasSize.h * pixelRatio.value;
    // 同时设置 CSS 宽高，保证视觉尺寸不变
    canvasRef.value.style.width = canvasSize.w + "px";
    canvasRef.value.style.height = canvasSize.h + "px";
    // 把绘图上下文的坐标系也放大到对应的像素比
    context.value.scale(pixelRatio.value, pixelRatio.value);
  }
}

// 随机生成一个粒子的初始参数
// 这么写会让每个粒子在位置、大小、透明度变化速度等方面都有差异，看起来更自然
function circleParams(): Circle {
  // 在当前画布宽高范围内随机生成起始坐标
  const x = Math.floor(Math.random() * canvasSize.w);
  const y = Math.floor(Math.random() * canvasSize.h);
  const translateX = 0;
  const translateY = 0;
  // 粒子的半径在 1~2 像素之间随机
  const size = Math.floor(Math.random() * 2) + 1;
  // 初始透明度为 0，从完全看不见慢慢淡入
  const alpha = 0;
  // 目标透明度在 0.1~0.7 之间随机，数字越大粒子越明显
  const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1));
  // dx / dy 控制粒子在 x / y 方向上的速度，允许是正也允许是负
  const dx = (Math.random() - 0.5) * 0.2;
  const dy = (Math.random() - 0.5) * 0.2;
  // magnetism 控制每个粒子受鼠标“磁力”影响的程度，数值越大越容易被吸引
  const magnetism = 0.1 + Math.random() * 4;
  return {
    x,
    y,
    translateX,
    translateY,
    size,
    alpha,
    targetAlpha,
    dx,
    dy,
    magnetism,
  };
}

// 负责真正把一个粒子画到 canvas 上
// update = false 时表示是新建粒子，需要把它加入数组；true 表示只是重画，不再重复加入
function drawCircle(circle: Circle, update = false) {
  if (context.value) {
    const { x, y, translateX, translateY, size, alpha } = circle;
    // 先把画布坐标系整体平移，再画圆，这样可以用 translateX/translateY 来实现粒子“跟随鼠标”的偏移
    context.value.translate(translateX, translateY);
    context.value.beginPath();
    context.value.arc(x, y, size, 0, 2 * Math.PI);
    // 使用 rgba 颜色，alpha 控制透明度，实现淡入淡出的效果
    context.value.fillStyle = `rgba(${color.value.split(" ").join(", ")}, ${alpha})`;
    context.value.fill();
    // 每次画完之后把坐标系恢复成原始状态，避免平移累积
    context.value.setTransform(pixelRatio.value, 0, 0, pixelRatio.value, 0, 0);

    if (!update) {
      // 只有在初次创建粒子时才把它放进数组，避免重复添加
      circles.value.push(circle);
    }
  }
}

// 清空画布上的所有内容
// 为什么要这么写：每一帧动画都需要先擦除上一帧的内容，再重新绘制最新的位置
function clearContext() {
  if (context.value) {
    context.value.clearRect(0, 0, canvasSize.w, canvasSize.h);
  }
}

// 生成多颗粒子并绘制到画布上
// 这么写可以根据 props.quantity 控制粒子数量，轻松做出“多/少粒子”的效果
function drawParticles() {
  // 先清空画布，避免旧的粒子残影
  clearContext();
  const particleCount = props.quantity;
  for (let i = 0; i < particleCount; i++) {
    const circle = circleParams();
    drawCircle(circle);
  }
}

// 工具函数：把一个数值从某个范围映射到另一个范围
// 举例：把 0~20 的距离映射到 0~1 的比例，用在计算粒子离边缘有多近
// 这么写的好处：这段“区间映射”的逻辑可以在多处复用，而不用每次重写公式
function remapValue(
  value: number,
  start1: number,
  end1: number,
  start2: number,
  end2: number,
): number {
  // 线性插值公式，把 value 从 [start1, end1] 映射到 [start2, end2]
  const remapped = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2;
  // 保证结果不为负数，避免后面出现“负透明度”等奇怪情况
  return remapped > 0 ? remapped : 0;
}

// 核心动画函数：每一帧都由 requestAnimationFrame 调用一次
// 主要工作：更新每个粒子的透明度、位置，然后重新绘制全部粒子
function animate() {
  // 先清空上一帧的画面，再绘制当前帧
  clearContext();
  circles.value.forEach((circle, i) => {
    // 1）根据粒子距离画布边缘的最近距离，决定它应该有多透明
    const edge = [
      // 粒子到左边缘的距离
      circle.x + circle.translateX - circle.size,
      // 粒子到右边缘的距离
      canvasSize.w - circle.x - circle.translateX - circle.size,
      // 粒子到上边缘的距离
      circle.y + circle.translateY - circle.size,
      // 粒子到下边缘的距离
      canvasSize.h - circle.y - circle.translateY - circle.size,
    ];

    // 找出离 4 个边缘中最近的那个距离
    const closestEdge = edge.reduce((a, b) => Math.min(a, b));
    // 把“最近距离 0~20 像素”映射成 0~1 的比例，用来控制透明度
    const remapClosestEdge = parseFloat(remapValue(closestEdge, 0, 20, 0, 1).toFixed(2));

    // 粒子在画布中间区域时，慢慢淡入到目标透明度
    if (remapClosestEdge > 1) {
      circle.alpha += 0.02;
      if (circle.alpha > circle.targetAlpha) circle.alpha = circle.targetAlpha;
    } else {
      // 靠近边缘时，根据 remapClosestEdge 把透明度压低，形成“边缘淡出”的效果
      circle.alpha = circle.targetAlpha * remapClosestEdge;
    }

    // 2）更新粒子自身的坐标（基础漂移动画）
    circle.x += circle.dx;
    circle.y += circle.dy;
    // 3）根据鼠标位置计算额外的平移，让粒子向鼠标靠近或远离
    circle.translateX +=
      (mouse.x / (props.staticity / circle.magnetism) - circle.translateX) / props.ease;
    circle.translateY +=
      (mouse.y / (props.staticity / circle.magnetism) - circle.translateY) / props.ease;

    // 4）如果粒子完全飞出了画布，就把它移出数组并创建一个新的粒子替代它
    if (
      circle.x < -circle.size ||
      circle.x > canvasSize.w + circle.size ||
      circle.y < -circle.size ||
      circle.y > canvasSize.h + circle.size
    ) {
      // 从数组中删除当前粒子，避免继续计算和绘制它
      circles.value.splice(i, 1);
      // 创建一个新的粒子，并立即绘制出来填补空位
      const newCircle = circleParams();
      drawCircle(newCircle);
    } else {
      // 否则就用更新后的参数重新绘制这个粒子
      drawCircle(
        {
          ...circle,
          x: circle.x,
          y: circle.y,
          translateX: circle.translateX,
          translateY: circle.translateY,
          alpha: circle.alpha,
        },
        true,
      );
    }
  });
  // 使用浏览器的 requestAnimationFrame 循环调用 animate
  // 这么写的好处：浏览器会在每一帧屏幕刷新时调用，既流畅又节能
  window.requestAnimationFrame(animate);
}
</script>
